Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit a96697b9a554c7ae068fb1b4a2bd19f34f918ce3


Parents : f55079c
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-26T06:37:05-05:00

feat: improve identity management by adding reauthentication requirements, improve job handling with identity scoping, and update LXMF message processing

Changes
Diff

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 28f488db..963fd2bd 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index f6a2ec9a..e2f7cbb3 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -2884,6 +2884,7 @@ class ReticulumMeshChat:
if hasattr(self, "config")
else "Unknown"
),
+ "requires_reauth": bool(self.auth_enabled),
},
),
)
@@ -9368,6 +9369,7 @@ class ReticulumMeshChat:
)
if is_local_self:
+ lxmf_message.pack()
lxmf_message.state = LXMF.LXMessage.DELIVERED
lxmf_message.progress = 1.0
local_peer = ctx.local_lxmf_destination.hexhash

diff --git a/meshchatx/src/backend/http/routes/identities.py b/meshchatx/src/backend/http/routes/identities.py
index a573f4c6..b06b0be3 100644
--- a/meshchatx/src/backend/http/routes/identities.py
+++ b/meshchatx/src/backend/http/routes/identities.py
@@ -385,6 +385,7 @@ def register_identities_routes(routes, app):
"hotswapped": True,
"identity_hash": identity_hash,
"display_name": display_name,
+ "requires_reauth": bool(app.auth_enabled),
},
)
# fallback to restart if hotswap failed

diff --git a/meshchatx/src/backend/http/routes/lxmf.py b/meshchatx/src/backend/http/routes/lxmf.py
index 3f122e32..8996bff7 100644
--- a/meshchatx/src/backend/http/routes/lxmf.py
+++ b/meshchatx/src/backend/http/routes/lxmf.py
@@ -656,6 +656,7 @@ def register_lxmf_routes(routes, app):
app_extensions=validated_app_extensions,
)
+ is_local_self = app._is_self_lxmf_destination(destination_hash)
return web.json_response(
{
"lxmf_message": convert_lxmf_message_to_dict(
@@ -665,6 +666,8 @@ def register_lxmf_routes(routes, app):
message_router=app.current_context.message_router
if app.current_context
else None,
+ state_override="delivered" if is_local_self else None,
+ method_override="local" if is_local_self else None,
),
},
)

diff --git a/meshchatx/src/backend/http/routes/map.py b/meshchatx/src/backend/http/routes/map.py
index 290139f0..48e70b8c 100644
--- a/meshchatx/src/backend/http/routes/map.py
+++ b/meshchatx/src/backend/http/routes/map.py
@@ -326,7 +326,8 @@ def register_map_routes(routes, app):
if not app.map_overlay_manager:
return web.json_response({"error": "unavailable"}, status=503)
job_id = request.match_info.get("job_id")
- job = app.map_overlay_manager.get_job(job_id)
+ identity_hash = app.identity.hash.hex()
+ job = app.map_overlay_manager.get_job(job_id, identity_hash=identity_hash)
if not job:
return web.json_response({"error": "not_found"}, status=404)
return web.json_response(job)
@@ -336,7 +337,8 @@ def register_map_routes(routes, app):
if not app.map_overlay_manager:
return web.json_response({"error": "unavailable"}, status=503)
job_id = request.match_info.get("job_id")
- ok = app.map_overlay_manager.cancel_job(job_id)
+ identity_hash = app.identity.hash.hex()
+ ok = app.map_overlay_manager.cancel_job(job_id, identity_hash=identity_hash)
if not ok:
return web.json_response({"error": "not_found"}, status=404)
return web.json_response({"cancelled": True})

diff --git a/meshchatx/src/backend/lxmf_utils.py b/meshchatx/src/backend/lxmf_utils.py
index 68db906a..51a128e2 100644
--- a/meshchatx/src/backend/lxmf_utils.py
+++ b/meshchatx/src/backend/lxmf_utils.py
@@ -587,6 +587,8 @@ def convert_lxmf_message_to_dict(
include_attachments: bool = True,
reticulum=None,
message_router=None,
+ state_override: str | None = None,
+ method_override: str | None = None,
):
# handle fields
fields = {}
@@ -777,9 +779,13 @@ def convert_lxmf_message_to_dict(
"source_hash": lxmf_message.source_hash.hex(),
"destination_hash": lxmf_message.destination_hash.hex(),
"is_incoming": lxmf_message.incoming,
- "state": convert_lxmf_state_to_string(lxmf_message),
+ "state": state_override
+ if state_override is not None
+ else convert_lxmf_state_to_string(lxmf_message),
"progress": progress_percentage,
- "method": convert_lxmf_method_to_string(lxmf_message),
+ "method": method_override
+ if method_override is not None
+ else convert_lxmf_method_to_string(lxmf_message),
"delivery_attempts": lxmf_message.delivery_attempts,
"next_delivery_attempt_at": getattr(
lxmf_message,

diff --git a/meshchatx/src/backend/map_overlay_manager.py b/meshchatx/src/backend/map_overlay_manager.py
index 0fa6b03c..81439fe1 100644
--- a/meshchatx/src/backend/map_overlay_manager.py
+++ b/meshchatx/src/backend/map_overlay_manager.py
@@ -222,8 +222,13 @@ class MapOverlayManager:
return None
return _row_to_dict(row)
- def get_job(self, job_id: str) -> dict[str, Any] | None:
- return self._jobs.get(job_id)
+ def get_job(self, job_id: str, identity_hash: str | None = None) -> dict[str, Any] | None:
+ job = self._jobs.get(job_id)
+ if job is None:
+ return None
+ if identity_hash is not None and job.get("identity_hash") != identity_hash:
+ return None
+ return job
async def create_overlays(
self,
@@ -713,10 +718,12 @@ class MapOverlayManager:
cache_relpath=rel,
)
- def cancel_job(self, job_id: str) -> bool:
+ def cancel_job(self, job_id: str, identity_hash: str | None = None) -> bool:
job = self._jobs.get(job_id)
if not job or job.get("status") not in ("running",):
return False
+ if identity_hash is not None and job.get("identity_hash") != identity_hash:
+ return False
fetcher = self._active_fetchers.get(job_id)
if fetcher is not None:
try:
@@ -876,6 +883,12 @@ class MapOverlayManager:
def cleanup(self) -> None:
self.stop_scheduler()
+ for job_id in list(self._jobs.keys()):
+ job = self._jobs.get(job_id)
+ if job and job.get("status") == "running":
+ self.cancel_job(job_id)
+ self._jobs.clear()
+ self._active_fetchers.clear()
work = os.path.join(self.overlay_root(), ".work")
if os.path.isdir(work):
shutil.rmtree(work, ignore_errors=True)

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index ea957dbd..a545ffd6 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1436,6 +1436,21 @@ export default {
ToastUtils.success(this.$t("identities.switched"));
+ if (json?.requires_reauth && GlobalState.authEnabled) {
+ GlobalState.authenticated = false;
+ try {
+ await fetchCsrfToken(window.api);
+ } catch {
+ // Next mutating request will refresh CSRF when auth completes.
+ }
+ if (this.$route?.name !== "auth") {
+ this.$router.push("/auth");
+ }
+ this.isSwitchingIdentity = false;
+ GlobalEmitter.emit("identity-switched", json);
+ return;
+ }
+
GlobalState.unreadConversationsCount = 0;
GlobalState.missedCallsCount = 0;
GlobalState.blockedDestinations = [];

diff --git a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
index b9bcf24f..0b6583d2 100644
--- a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
+++ b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
@@ -684,6 +684,7 @@ export default {
GlobalEmitter.emit("identity-switched-apply", {
identity_hash: response.data.identity_hash ?? identity.hash,
display_name: response.data.display_name ?? identity.display_name ?? "",
+ requires_reauth: Boolean(response.data.requires_reauth),
});
} else {
ToastUtils.info(this.$t("identities.switch_scheduled"));

diff --git a/tests/backend/test_identity_switch_oracles.py b/tests/backend/test_identity_switch_oracles.py
index 5f7cb1ab..76c8fed4 100644
--- a/tests/backend/test_identity_switch_oracles.py
+++ b/tests/backend/test_identity_switch_oracles.py
@@ -162,7 +162,67 @@ async def test_oracle_concurrent_hotswap_serializes_critical_section(
@pytest.mark.asyncio
-async def test_oracle_delete_identity_evicts_keep_alive_context(mock_rns, temp_dir):
+async def test_oracle_hotswap_broadcast_requires_reauth_when_auth_enabled(
+ mock_rns,
+ temp_dir,
+):
+ app = ReticulumMeshChat(
+ identity=mock_rns["id_instance"],
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ auth_enabled=True,
+ )
+ app.websocket_broadcast = AsyncMock()
+ app.teardown_identity = MagicMock()
+ mock_ctx = MagicMock()
+ mock_ctx.config.display_name.get.return_value = "User"
+ mock_ctx.config.auth_enabled.get.return_value = True
+ app.setup_identity = MagicMock(side_effect=lambda _id: setattr(app, "current_context", mock_ctx))
+
+ new_hash = "ee" * 16
+ _write_identity_tree(temp_dir, new_hash, b"key")
+ new_id = MagicMock()
+ new_id.hash = bytes.fromhex(new_hash)
+ mock_rns["Identity"].from_file.return_value = new_id
+
+ with patch("meshchatx.meshchat.asyncio.sleep", new=AsyncMock()):
+ await app.hotswap_identity(new_hash)
+
+ payload = json.loads(app.websocket_broadcast.call_args[0][0])
+ assert payload["type"] == "identity_switched"
+ assert payload["requires_reauth"] is True
+
+
+@pytest.mark.asyncio
+async def test_oracle_hotswap_broadcast_no_reauth_when_auth_disabled(
+ mock_rns,
+ temp_dir,
+):
+ app = ReticulumMeshChat(
+ identity=mock_rns["id_instance"],
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ auth_enabled=False,
+ )
+ app.websocket_broadcast = AsyncMock()
+ app.teardown_identity = MagicMock()
+ mock_ctx = MagicMock()
+ mock_ctx.config.display_name.get.return_value = "User"
+ mock_ctx.config.auth_enabled.get.return_value = False
+ app.setup_identity = MagicMock(side_effect=lambda _id: setattr(app, "current_context", mock_ctx))
+
+ new_hash = "ff" * 16
+ _write_identity_tree(temp_dir, new_hash, b"key")
+ new_id = MagicMock()
+ new_id.hash = bytes.fromhex(new_hash)
+ mock_rns["Identity"].from_file.return_value = new_id
+
+ with patch("meshchatx.meshchat.asyncio.sleep", new=AsyncMock()):
+ await app.hotswap_identity(new_hash)
+
+ payload = json.loads(app.websocket_broadcast.call_args[0][0])
+ assert payload.get("requires_reauth") is False
+
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,

diff --git a/tests/backend/test_map_overlay_manager.py b/tests/backend/test_map_overlay_manager.py
index eb83069d..562c51f5 100644
--- a/tests/backend/test_map_overlay_manager.py
+++ b/tests/backend/test_map_overlay_manager.py
@@ -465,3 +465,44 @@ async def test_cancel_job(manager):
await asyncio.wait_for(started.wait(), timeout=2)
assert manager.cancel_job(job_id) is True
assert manager.get_job(job_id)["status"] == "cancelled"
+
+
+@pytest.mark.asyncio
+async def test_cleanup_cancels_running_overlay_job(manager):
+ identity = "id_cleanup"
+ started = asyncio.Event()
+
+ class FakeDownloader:
+ def __init__(self, **kwargs):
+ self._failure = kwargs["on_file_download_failure"]
+ self.cancelled = False
+
+ def cancel(self):
+ self.cancelled = True
+ self._failure("cancelled")
+
+ async def download(self, **_kwargs):
+ started.set()
+ await asyncio.sleep(10)
+
+ manager._file_downloader_factory = lambda **kw: FakeDownloader(**kw)
+ created = await manager.create_overlays(
+ identity,
+ {"kind": "nomadnet_file", "url": f"{HASH}:/file/layer.geojson"},
+ )
+ job_id = created["job_id"]
+ await asyncio.wait_for(started.wait(), timeout=2)
+ manager.cleanup()
+ assert manager.get_job(job_id) is None
+
+
+def test_get_job_and_cancel_scoped_to_identity_hash(manager):
+ manager._jobs["job-a"] = {
+ "job_id": "job-a",
+ "identity_hash": "aa" * 16,
+ "status": "running",
+ "overlay_ids": [],
+ }
+ assert manager.get_job("job-a", identity_hash="bb" * 16) is None
+ assert manager.get_job("job-a", identity_hash="aa" * 16) is not None
+ assert manager.cancel_job("job-a", identity_hash="bb" * 16) is False

diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js
index 3c747e5d..89317863 100644
--- a/tests/e2e/helpers.js
+++ b/tests/e2e/helpers.js
@@ -8,23 +8,16 @@ const CSRF_HEADER = "X-CSRF-Token";
const E2E_SCROLL_PEER_HASH = `e2e0${"0".repeat(28)}`;
const E2E_SCROLL_ALT_PEER_HASH = `e2e1${"0".repeat(28)}`;
-const _csrfByOrigin = new Map();
-
/**
* @param {import('@playwright/test').APIRequestContext} request
* @param {string} origin e.g. http://127.0.0.1:18079
*/
async function ensureE2eCsrf(request, origin) {
- const cached = _csrfByOrigin.get(origin);
- if (cached) {
- return cached;
- }
const res = await request.get(`${origin}/api/v1/auth/csrf`);
expect(res.ok()).toBeTruthy();
const body = await res.json();
const token = body.csrf_token;
expect(token && typeof token === "string").toBeTruthy();
- _csrfByOrigin.set(origin, token);
return token;
}


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────